ファイルのストリーミング強制保存をクロスオリジンでも実現させるService Workerの裏技ぽい使い方
#ワークアラウンド #workaround
やりたいこと
同一オリジンでないURLをブラウザで開かずにファイルとして保存させたい。そして大きいファイルでもダウロードできるようにBlobとかBlob URLとかを使わないで解決したい。
勘違いされやすいところ: Access-Control-Allow-Origin: *がなくてもダウンロードという意味ではない。
例えば、mp4動画https://nwtgck.github.io/videos/portfolio/ray-tracing-iow-scala.mp4をクリックするブラウザ上で動画がインラインで再生される。<a download href="https://nwtgck.github.io/...-scala.mp4">としてもnwtgck.github.ioでないとファイルとしてダウンロードされない。
詳細: <a>のdownload属性は同一オリジンじゃないとダウンロード出来ない
以下に実際にダウンロード出来るデモ動画。すごくなんの変哲もなく動いているように見えるが、<a>のdownload属性は同一オリジンじゃないとダウンロード出来ないの制約があるので、それをくぐり抜けている。
デモ動画
https://youtu.be/hvdaJ-Kq0OI
ダウンロード中に円状に進捗が出ていることからストリーミングしながらダウンロードできていることが分かる。
4.8MBなのに遅いのはデモ動画撮ったネットワーク環境が悪かったから。この手法で遅くなっているわけではないことに注意
仕組みとしては、StreamSaver.jsが使っているものと同じ。Firefox Sendもこれと同じ方法を内部で使っていたはず。
Piping UIで使っている。
詳細: ReadableStreamをストリーミングしながらダウンロードできるStreamSaver.jsの仕組みを読み解きたい
このページではその技術をなるべく余計なものを排除して載せることが目的。
Service Workerのキャッシュ以外の使い方
Service Workerといえば、
PWAとかでファイルキャッシュして
ネットワークなしで表示とか
早く表示するとか
とかが思いつくが、
Service Workerは
HTTPのプロキシサーバー的に使うことができ、
内部でクロスオリジンのサイトをfetch()したり、
HTTPヘッダを書き換えたり
などが出来プロキシサーバーのように見える。
大雑把な仕組み
mp4動画https://nwtgck.github.io/videos/portfolio/ray-tracing-iow-scala.mp4が強制保存されるまでについて。
Download ボタンが押される。
GET /swdownload?url=https://nwtgck.github.io...-scala.mp4&filename=myvideo.mp4 のリクエストが出る
JavaScriptで<a href="mp4動画のURL" target="_blank">が動的に生成されて.click()されることでGETリクエストされる。
Service Workerのonfetchを通る。
event.respondWith(fetch(mp4動画のURL))でレスポンスが変える
Content-Disposition: attachmentしてブラウザインライン化されないようにHTTPヘッダをService Workerが書き換える。
つまり、ファイルは/index.htmlと/service-worker.jsしかないにもかかわらず、Service Workerが/swdownloadというパスに対するリクエストにレスポンスを返せるようなHTTPサーバーのように振る舞う。
実際のコード
GitHubリポジトリ:
git cloneしてpython3 -m http.serverとか使ってlocalhost:8000を立ててService Workerが動くようにして試せば良い。
code:index.html
<html>
<head></head>
<body>
<script>
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js');
});
}
function download() {
// Get values from inputs
const url = window.target_url.value;
const filename = window.filename.value;
const a = document.createElement('a');
a.href = /swdonwload?url=${encodeURIComponent(url)}&filename=${filename};
a.target = '_blank';
a.click();
}
</script>
<input type="text" id="target_url" placeholder="URL" size="100"><br>
<input type="text" id="filename" placeholder="File name"><br>
<button onclick="download()">Download</button>
</body>
</html>
code:service-worker.js
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname === '/swdonwload') {
const targetUrl = url.searchParams.get('url');
const filename = url.searchParams.get('filename');
event.respondWith((async () => {
const res = await fetch(targetUrl);
const headers = new Headers(...res.headers.entries());
headers.set('Content-Disposition', attachment; filename="${filename}");
const downloadableRes = new Response(res.body, {
headers
});
return downloadableRes;
})());
}
});
対応ブラウザ
Service Workerが対応していれば動くはず。ただし、Safariが対応していない。これはStreamSaver.jsでもissueになっていてSafariのバグではないかとのこと。
Safari非対応に関してはSafari support detection · Issue #69 · jimmywarting/StreamSaver.jsに情報がある。
対応をJavaScript側で確認したい
Service Workerに/swdownload-supportにアクセスされたら決まったResponse()返すようにして、その/swdownload-supportに向かってUI側のJavaScriptでfetch()とかしてちゃんとレスポンスが返ってくるかどうかで判定すれば良いと思う。
実際にこの技術を使っているPiping UIの該当コードは以下。
Service Worker側: https://github.com/nwtgck/piping-ui-web/blob/df321836fe420b0a96a7b228cc134010d858da4f/src/sw.js#L23-L32
UIのJS側:
Piping UIでは呼び出し側の関係でretry回数があるが、ロジックとしてはシンプルにリトライはなし良いと思う。